🔢 Gli Array in C

Guida Completa, Esaustiva e Approfondita alle Strutture Dati Fondamentali

📚 Introduzione: Il Ruolo Fondamentale degli Array nella Programmazione

Immaginiamo di dover gestire i dati di un'intera classe universitaria: 30 studenti, ognuno con il proprio voto, nome, matricola. Se dovessimo creare una variabile separata per ogni dato, ci troveremmo rapidamente in una situazione ingestibile. Avremmo bisogno di dichiarare: int voto_studente_1, voto_studente_2, voto_studente_3... fino ad arrivare a voto_studente_30. E questo solo per i voti! Se volessimo calcolare la media, dovremmo scrivere manualmente: (voto_studente_1 + voto_studente_2 + ... + voto_studente_30) / 30. Una soluzione del genere è chiaramente impraticabile, inefficiente e fonte di errori.

Questa situazione diventa ancora più critica quando i dati non sono noti a priori. E se gli studenti fossero 100? O 1000? Come potremmo gestire una situazione in cui il numero di elementi varia a runtime? Senza una struttura dati appropriata, saremmo completamente bloccati. È qui che entrano in gioco gli array, una delle strutture dati più fondamentali e potenti della programmazione.

Un array è molto più di una semplice collezione di variabili: è una struttura ordinata che memorizza elementi omogenei (tutti dello stesso tipo) in posizioni di memoria contigue. Questa definizione apparentemente semplice nasconde una complessità e un'eleganza notevoli. La contiguità in memoria non è solo una caratteristica implementativa, ma è la chiave che rende gli array incredibilmente efficienti: permette l'accesso diretto a qualsiasi elemento in tempo costante O(1), indipendentemente dalla dimensione dell'array.

Gli array in C sono strettamente legati al concetto di puntatore, tanto che in molti contesti il nome di un array "decade" automaticamente in un puntatore al suo primo elemento. Questa relazione intima tra array e puntatori è una delle caratteristiche distintive del C, che lo rende un linguaggio estremamente potente per la programmazione di sistema e l'accesso diretto alla memoria. Tuttavia, questa potenza viene con una responsabilità: il C non effettua controlli automatici sui limiti degli array, lasciando al programmatore la responsabilità di evitare accessi fuori dai confini, che possono portare a comportamenti indefiniti, corruzione dei dati o vulnerabilità di sicurezza.

In questa lezione, esploreremo ogni aspetto degli array in profondità. Partiremo dalle basi teoriche, comprendendo come sono rappresentati in memoria e come il processore li gestisce a livello hardware. Analizzeremo tutte le tecniche di dichiarazione e inizializzazione, dalle più semplici alle più avanzate. Studieremo il rapporto complesso ma fondamentale tra array e puntatori, scoprendo come l'aritmetica dei puntatori permette di navigare efficientemente tra gli elementi. Affronteremo le stringhe come casi speciali di array di caratteri, gli array multidimensionali e le matrici, l'allocazione dinamica della memoria, e le best practices per scrivere codice sicuro e professionale. Vedremo anche gli errori più comuni e come evitarli, perché la conoscenza degli errori tipici è spesso più preziosa della conoscenza delle pratiche corrette.

Preparati per un viaggio approfondito nel cuore del C. Al termine di questa lezione, non solo saprai usare gli array, ma comprenderai intimamente come funzionano a livello di memoria e processore, perché sono progettati così, e come sfruttarli al meglio per scrivere codice efficiente, robusto e professionale.

1. Concetti Fondamentali e Architettura degli Array

1.1 Definizione Formale e Caratteristiche Essenziali

Un array, nella sua definizione più rigorosa, è una struttura dati lineare che rappresenta una sequenza finita e ordinata di elementi dello stesso tipo, memorizzati in locazioni di memoria consecutive. Questa definizione contiene diversi concetti chiave che meritano un'analisi approfondita.

La linearità dell'array significa che esiste un ordine naturale tra gli elementi: c'è un primo elemento, un secondo elemento, e così via fino all'ultimo. Questo ordine è determinato dalla posizione fisica in memoria e non può essere modificato senza ricostruire l'intero array. La linearità è ciò che permette di usare un singolo indice intero per accedere a qualsiasi elemento.

L'omogeneità (tutti gli elementi dello stesso tipo) non è una limitazione arbitraria, ma una necessità architettonica. Il processore deve sapere esattamente quanti byte occupa ogni elemento per poter calcolare l'indirizzo di memoria di qualsiasi elemento dato il suo indice. Se gli elementi avessero dimensioni diverse, sarebbe impossibile saltare direttamente all'elemento desiderato senza scansionare tutti quelli precedenti. Questa omogeneità è ciò che rende possibile l'accesso in tempo costante O(1).

La contiguità in memoria è forse la caratteristica più importante e distintiva degli array. Significa che se il primo elemento dell'array si trova all'indirizzo 0x1000, il secondo elemento si troverà esattamente a 0x1000 + sizeof(elemento), il terzo a 0x1000 + 2*sizeof(elemento), e così via. Non ci sono "buchi" o interruzioni nella sequenza di memoria. Questa disposizione ha profonde implicazioni:

🔑 Le Quattro Caratteristiche Essenziali degli Array in C:
  • 1. Omogeneità Tipologica: Tutti gli elementi devono essere dello stesso tipo di dato. Non è possibile avere un array che contiene sia interi che decimali (senza ricorrere a tecniche avanzate come le union o i void pointer). Questa restrizione permette al compilatore di generare codice estremamente efficiente per l'accesso agli elementi, poiché può calcolare l'indirizzo di qualsiasi elemento con una semplice moltiplicazione e addizione: indirizzo_base + (indice × sizeof(tipo)).
  • 2. Dimensione Fissa (Arrays Statici): In C standard (C89/90), la dimensione di un array deve essere una costante nota al momento della compilazione. Questo permette al compilatore di allocare lo spazio necessario direttamente nello stack frame della funzione. C99 ha introdotto i Variable Length Arrays (VLA) che permettono dimensioni determinate a runtime, ma questa feature è opzionale in C11 e non supportata da tutti i compilatori. Per dimensioni veramente dinamiche, si usa l'allocazione heap con malloc().
  • 3. Contiguità in Memoria: Gli elementi sono memorizzati uno immediatamente dopo l'altro in memoria, senza interruzioni. Questo ha due implicazioni cruciali. Prima: l'accesso sequenziale agli elementi è estremamente efficiente grazie alla cache del processore (locality of reference). Quando accedi a arr[i], il processore carica in cache anche gli elementi vicini, rendendo gli accessi successivi praticamente istantanei. Seconda: devi allocare un blocco contiguo di memoria sufficientemente grande, il che può essere problematico per array molto grandi in sistemi con memoria frammentata.
  • 4. Indicizzazione Base-Zero: Il primo elemento ha indice 0, non 1. Questa scelta non è arbitraria ma deriva direttamente dall'implementazione: l'indice rappresenta l'offset (scostamento) rispetto all'indirizzo base. L'elemento 0 è a offset 0 (nessuno scostamento), l'elemento 1 è a offset sizeof(elemento), e così via. La formula diventa semplicemente: indirizzo_elemento = indirizzo_base + (indice × sizeof(elemento)). Questa convenzione rende l'accesso agli array estremamente efficiente a livello di assembly.

1.2 Rappresentazione in Memoria: Dal Concetto all'Implementazione Hardware

Comprendere come gli array sono memorizzati e gestiti a livello di memoria RAM e processore è fondamentale per capirne il comportamento, le prestazioni e i limiti. Quando dichiari un array, stai istruendo il compilatore a riservare un blocco contiguo di memoria. Ma cosa significa esattamente "contiguo" e come viene gestito questo blocco?

Consideriamo la dichiarazione int numeri[5] = {10, 20, 30, 40, 50};. Assumendo che questo array sia dichiarato come variabile locale in una funzione, il compilatore riserverà spazio nello stack frame della funzione. Lo stack cresce verso indirizzi di memoria decrescenti (su architetture x86/x86-64), quindi gli elementi potrebbero essere disposti in ordine inverso rispetto alla crescita dello stack. Tuttavia, all'interno dell'array stesso, gli elementi sono sempre in ordine crescente di indirizzi.

Supponiamo che il primo elemento dell'array sia allocato all'indirizzo 0x1000 (in esadecimale). Su un sistema a 32 o 64 bit, ogni int tipicamente occupa 4 byte. Quindi:

Rappresentazione Dettagliata in Memoria: int numeri[5] = {10, 20, 30, 40, 50};

numeri[0]
10
0x1000
Byte 0-3
numeri[1]
20
0x1004
Byte 4-7
numeri[2]
30
0x1008
Byte 8-11
numeri[3]
40
0x100C
Byte 12-15
numeri[4]
50
0x1010
Byte 16-19

Analisi Dettagliata:

  • Ogni elemento occupa esattamente 4 byte (32 bit)
  • Gli indirizzi sono incrementati di 4 byte (0x4 in esadecimale) tra elementi consecutivi
  • L'array occupa totalmente 20 byte consecutivi (5 elementi × 4 byte)
  • Non ci sono byte sprecati o "padding" tra gli elementi
  • L'indirizzo dell'elemento i è: 0x1000 + (i × 4)

Questa disposizione ha implicazioni profonde per le prestazioni. I processori moderni utilizzano una gerarchia di memoria: registri (velocissimi ma pochi), cache L1/L2/L3 (veloci e di dimensione crescente), RAM (più lenta ma capiente), e storage (molto lento). Quando il processore accede a un indirizzo di memoria, non legge solo quel singolo byte, ma carica un'intera cache line (tipicamente 64 byte) che contiene quell'indirizzo e i byte circostanti.

Grazie alla contiguità degli array, quando accedi a numeri[0], la cache line caricata conterrà molto probabilmente anche numeri[1], numeri[2], ecc. Gli accessi successivi a questi elementi saranno serviti dalla cache, risultando praticamente istantanei. Questo fenomeno è chiamato locality of reference (località di riferimento) ed è uno dei motivi per cui gli array sono così efficienti per elaborazioni sequenziali.

⚠️ Implicazioni della Contiguità: Vantaggi e Limitazioni

La contiguità in memoria è un'arma a doppio taglio. Da un lato, offre prestazioni eccellenti per accessi sequenziali e casuali, e sfrutta al meglio la cache del processore. D'altro lato, impone vincoli stringenti:

  • Allocazione Atomica: L'array deve essere allocato come un unico blocco. Se stai cercando di allocare un array di 1 milione di elementi (4 MB per interi), ma la memoria è frammentata e non esiste un blocco contiguo di 4 MB, l'allocazione fallirà anche se hai 100 MB di memoria totale disponibile.
  • Limite dello Stack: Gli array locali (variabili automatiche) sono allocati sullo stack, che tipicamente ha una dimensione limitata (spesso 1-8 MB). Dichiarare un array molto grande come variabile locale può causare stack overflow.
  • Ridimensionamento Costoso: Non puoi "allargare" un array esistente. Se hai bisogno di più spazio, devi allocare un nuovo array più grande e copiare tutti gli elementi, operazione che ha complessità O(n).

Per questi motivi, quando hai bisogno di collezioni di dimensione variabile o molto grandi, spesso è meglio usare strutture dati più complesse (liste concatenate, array dinamici con capacità crescente, ecc.) o allocare memoria sull'heap con malloc().

2. Dichiarazione e Inizializzazione: Sintassi, Semantica e Best Practices

2.1 Anatomia della Dichiarazione: Ogni Componente ha il suo Significato

La dichiarazione di un array in C segue una sintassi precisa che comunica al compilatore tre informazioni essenziali. Analizziamo in dettaglio ogni componente della sintassi:

// Sintassi generale della dichiarazione di array
tipo_dato nome_array[dimensione];

// Scomposizione della sintassi:
// 1. tipo_dato: specifica il tipo degli elementi (int, float, char, ecc.)
// 2. nome_array: identificatore dell'array (segue le regole dei nomi di variabili)
// 3. [dimensione]: numero di elementi, deve essere una costante intera > 0

// Esempi concreti con diversi tipi
int voti[30];              // 30 interi (120 byte su sistema 32/64 bit)
float temperature[365];     // 365 float (1460 byte, ~1.4 KB)
char nome[50];             // 50 caratteri (50 byte) - per una stringa
double prezzi[100];        // 100 double (800 byte su sistema 64-bit)
long long grandi[20];      // 20 long long (160 byte)

// Array di tipi complessi
struct Studente studenti[50];   // Array di 50 struct Studente
int *puntatori[10];            // Array di 10 puntatori a int

Il tipo di dato non solo determina quanto spazio occupa ogni elemento, ma anche come vengono interpretati i byte in memoria. Un int viene interpretato come numero intero con segno, un float come numero in virgola mobile secondo lo standard IEEE 754, un char come singolo carattere ASCII o UTF-8. Il compilatore usa questa informazione per generare le istruzioni assembly corrette per operare su questi dati.

Il nome dell'array diventa un identificatore che, nella maggior parte dei contesti, si comporta come un puntatore costante al primo elemento. Questo significa che non puoi riassegnare il nome dell'array a un altro array (array1 = array2; è illegale), ma puoi usarlo ovunque sia atteso un puntatore al tipo base.

La dimensione deve essere un'espressione costante valutabile al momento della compilazione. Può essere un letterale intero (10), una costante simbolica (#define SIZE 10), un'espressione costante (10 + 5), ma NON una variabile normale. Questo perché il compilatore deve sapere quanto spazio allocare nello stack frame della funzione.

📏 Requisiti e Limitazioni sulla Dimensione:

La dimensione dell'array è soggetta a diverse restrizioni che variano a seconda dello standard C utilizzato e delle caratteristiche del compilatore:

  • C89/C90 (ANSI C): La dimensione DEVE essere una costante nota al momento della compilazione. Non puoi scrivere:
    int n;
    printf("Quanti elementi? ");
    scanf("%d", &n);
    int array[n];  // ERRORE in C89/C90: n non è una costante!
  • C99: Introduce i Variable Length Arrays (VLA), che permettono dimensioni determinate a runtime. Il codice sopra diventa legale in C99. Tuttavia, i VLA sono allocati sullo stack, quindi dimensioni molto grandi possono causare stack overflow.
  • C11 e successivi: I VLA diventano una feature opzionale. Molti compilatori moderni li supportano come estensione, ma non è garantito. Per massima portabilità, usa costanti o allocazione dinamica.
  • Limiti pratici: Anche quando tecnicamente possibile, array molto grandi (>1MB) come variabili locali sono sconsigliati. Lo stack ha dimensione limitata e finita. Per array grandi, usa l'allocazione heap con malloc().

2.2 Inizializzazione: Tutti i Metodi, Tutti i Dettagli

L'inizializzazione di un array è un aspetto critico che troppo spesso viene trascurato. Un array non inizializzato contiene "garbage" (dati casuali lasciati in memoria da programmi precedenti o allocazioni precedenti), che può portare a bug estremamente difficili da individuare perché il comportamento diventa non deterministico. Esaminiamo in dettaglio ogni metodo di inizializzazione.

2.2.1 Inizializzazione Completa con Lista di Valori

Il metodo più esplicito e chiaro: specifichi esattamente il valore di ogni elemento. Il compilatore verifica che il numero di inizializzatori non superi la dimensione dichiarata.

// Inizializzazione esplicita con dimensione specificata
int numeri[5] = {10, 20, 30, 40, 50};
// Ogni elemento riceve esplicitamente il suo valore
// numeri[0] = 10, numeri[1] = 20, ..., numeri[4] = 50

// Inizializzazione con deduzione della dimensione
int giorni_mese[] = {31, 28, 31, 30, 31, 30, 
                      31, 31, 30, 31, 30, 31};
// Il compilatore conta gli inizializzatori: dimensione = 12
// Equivalente a: int giorni_mese[12] = {31, 28, ...};

// Per array di float o double, usa il punto decimale
float coefficienti[] = {1.5, 2.7, 3.14159, 2.71828};

// Anche per le stringhe (array di char) - nota il '\0' automatico
char saluto[] = "Ciao";  
// Equivalente a: char saluto[] = {'C', 'i', 'a', 'o', '\0'};
// Dimensione automatica: 5 (4 caratteri + terminatore null)

// Puoi anche essere esplicito con la dimensione per le stringhe
char buffer[100] = "Ciao";
// buffer ha spazio per 100 char, ma solo i primi 5 sono inizializzati
// buffer[0]='C', buffer[1]='i', ..., buffer[4]='\0', buffer[5...99]=0

2.2.2 Inizializzazione Parziale: Il Compilatore Completa con Zeri

Se fornisci meno inizializzatori della dimensione dell'array, il compilatore C garantisce che tutti gli elementi rimanenti vengono inizializzati a zero (o equivalente per il tipo: 0 per interi, 0.0 per float, '\0' per char, NULL per puntatori). Questa è una regola importante e utile.

// Inizializzazione parziale: solo i primi 3 elementi sono specificati
int numeri[10] = {1, 2, 3};
// Risultato: {1, 2, 3, 0, 0, 0, 0, 0, 0, 0}
// Gli elementi da indice 3 a 9 sono automaticamente inizializzati a 0

// Idioma comune: azzerare completamente un array
int array[100] = {0};  
// Tutti i 100 elementi sono 0
// Molto efficiente: il compilatore spesso usa istruzioni speciali per azzerare

float valori[50] = {0.0};  
// Tutti gli elementi sono 0.0

char stringa[256] = {0};  
// Tutti i caratteri sono '\0' (null character)

// ATTENZIONE: questo NON funziona per inizializzare a valori diversi da 0
int tutti_cinque[10] = {5};  
// NON inizializza tutti a 5!
// Risultato: {5, 0, 0, 0, 0, 0, 0, 0, 0, 0}
// Solo il primo elemento è 5, gli altri sono 0

// Per inizializzare tutti a un valore specifico, serve un loop
int tutti_cinque_corretto[10];
for(int i = 0; i < 10; i++) {
    tutti_cinque_corretto[i] = 5;
}
✓ Best Practice - Inizializzazione Difensiva:

Prendi l'abitudine di inizializzare sempre i tuoi array, anche quando pensi che scriverai successivamente tutti i valori. Questo previene bug subdoli dove potresti dimenticare di inizializzare alcuni elementi, o dove la logica del programma potrebbe cambiare lasciando alcuni elementi non scritti.

// BUONO: Inizializzazione difensiva
int buffer[1000] = {0};  // Tutti zero
// Ora puoi riempire il buffer senza preoccuparti di garbage
for(int i = 0; i < n && i < 1000; i++) {
    buffer[i] = calcola_valore(i);
}
// Gli elementi non scritti sono comunque 0, non garbage

// PERICOLOSO: Array non inizializzato
int buffer_pericoloso[1000];  // Contiene GARBAGE!
// Se il loop sopra non scrive tutti gli elementi, quelli rimanenti
// contengono dati casuali, potenzialmente pericolosi

L'overhead di inizializzare a zero è spesso trascurabile (i compilatori moderni usano istruzioni ottimizzate), ma i bug evitati sono inestimabili.

2.2.3 Designated Initializers: Inizializzazione Selettiva (C99)

A partire dallo standard C99, puoi specificare esplicitamente quali elementi inizializzare, lasciando gli altri a zero. Questa feature è particolarmente utile per array sparsi o quando vuoi inizializzare solo alcuni elementi chiave.

// Sintassi: [indice] = valore
// Gli elementi non menzionati vengono inizializzati a 0

int sparse[10] = {
    [0] = 5,      // sparse[0] = 5
    [3] = 10,     // sparse[3] = 10
    [9] = 15      // sparse[9] = 15
};
// Risultato: {5, 0, 0, 10, 0, 0, 0, 0, 0, 15}
// Gli indici 1, 2, 4, 5, 6, 7, 8 sono automaticamente 0

// Puoi anche mischiare inizializzatori designati e normali
int misto[10] = {
    1, 2,         // [0]=1, [1]=2 (sequenziali)
    [5] = 50,     // [5]=50 (salto a indice 5)
    60, 70        // [6]=60, [7]=70 (continua da 5)
};
// Risultato: {1, 2, 0, 0, 0, 50, 60, 70, 0, 0}

// Esempio pratico: array di configurazione con enum
enum Giorno { 
    LUNEDI, MARTEDI, MERCOLEDI, GIOVEDI, VENERDI, SABATO, DOMENICA 
};

int ore_lavoro[7] = {
    [LUNEDI]    = 8,
    [MARTEDI]   = 8,
    [MERCOLEDI] = 8,
    [GIOVEDI]   = 8,
    [VENERDI]   = 6,
    [SABATO]    = 0,
    [DOMENICA]  = 0
};
// Molto più leggibile e manutenibile rispetto a {8, 8, 8, 8, 6, 0, 0}

// Puoi anche usare espressioni costanti
#define SIZE 100
int bordi[SIZE] = {
    [0] = 1,           // Primo elemento
    [SIZE - 1] = 1     // Ultimo elemento
};
// Inizializza solo primo e ultimo elemento a 1, resto a 0

2.2.4 Array Non Inizializzati: Perché Sono Pericolosi

Questa sezione merita particolare attenzione perché l'inizializzazione mancante è una fonte comune di bug subdoli e difficili da trovare. Il comportamento degli array non inizializzati dipende da dove sono allocati (storage class).

💥 PERICOLO CRITICO - Array Non Inizializzati:

In C, esistono diverse "classi di storage" per le variabili, e ognuna ha regole diverse per l'inizializzazione implicita:

  • Variabili Automatiche (locali): Allocate sullo stack. NON sono inizializzate automaticamente. Contengono qualsiasi dato si trovasse in quella zona di memoria.
    void funzione() {
        int array[10];  // PERICOLOSO! Contiene GARBAGE
        
        // Stampare questi valori produce risultati completamente casuali e imprevedibili
        for(int i = 0; i < 10; i++) {
            printf("%d ", array[i]);  // Potrebbe stampare: 42 -1234567 0 891234 ...
        }
        
        // Usare questi valori in calcoli porta a risultati completamente sbagliati
        int somma = 0;
        for(int i = 0; i < 10; i++) {
            somma += array[i];  // Somma di numeri casuali!
        }
    }
  • Variabili Globali e Statiche: Allocate nel segmento dati. Vengono automaticamente inizializzate a zero dal loader del sistema operativo.
    int array_globale[100];  // Tutti zero automaticamente
    
    void funzione() {
        static int array_statico[50];  // Tutti zero automaticamente
        // Questi array sono sicuri anche senza inizializzazione esplicita
    }

Il problema: È facile dimenticare che solo le variabili globali/statiche sono auto-inizializzate. Le variabili locali NO! Molti bug derivano da questa confusione. La soluzione è semplice: inizializza sempre esplicitamente, così non devi ricordare le regole e il codice funziona sempre correttamente.

3.3 Iterazione sugli Array: Pattern e Ottimizzazioni

Iterare su tutti gli elementi di un array è una delle operazioni più comuni nella programmazione. Esistono diversi pattern per farlo, ognuno con vantaggi e considerazioni specifiche. La scelta del pattern giusto può fare la differenza tra codice leggibile e manutenibile versus codice oscuro e propenso a errori.

3.3.1 For Loop con Indice - Il Pattern Classico

Questo è il metodo più comune e leggibile, specialmente per principianti. Usa un indice intero che varia da 0 alla dimensione-1.

// Pattern base: iterazione con indice
int somma_array(int *array, size_t size) {
    int somma = 0;
    
    // Classico for loop: inizializzazione; condizione; incremento
    for(size_t i = 0; i < size; i++) {
        somma += array[i];
    }
    
    return somma;
}

// VANTAGGI del for con indice:
// 1. Leggibilità: chiaro e familiare
// 2. Accesso all'indice: se serve sapere la posizione
// 3. Facile debugging: puoi ispezionare 'i'
// 4. Modificabile: puoi cambiare il passo (i += 2) o direzione


// Esempi di variazioni utili:

// Iterazione al contrario
void stampa_inverso(int *array, size_t size) {
    for(size_t i = size; i > 0; i--) {
        printf("%d ", array[i - 1]);  // Nota: i-1 perché i parte da size
    }
    printf("\n");
}

// Alternativa più comune per iterazione inversa
void stampa_inverso_v2(int *array, size_t size) {
    for(int i = size - 1; i >= 0; i--) {  // Nota: i è signed int
        printf("%d ", array[i]);
    }
    printf("\n");
}

// Iterazione a salti (ogni 2 elementi, ecc.)
void stampa_pari(int *array, size_t size) {
    for(size_t i = 0; i < size; i += 2) {  // Incremento di 2
        printf("%d ", array[i]);
    }
    printf("\n");
}

// Pattern con uso dell'indice per operazioni posizionali
void moltiplica_per_posizione(int *array, size_t size) {
    for(size_t i = 0; i < size; i++) {
        array[i] *= (i + 1);  // Elemento i moltiplicato per la sua posizione (1-indexed)
    }
}

3.3.2 Iterazione con Puntatori - Lo Stile "C Idiomatico"

L'iterazione con puntatori è considerata più "idiomatica" in C e può essere leggermente più efficiente in alcuni casi. Invece di usare un indice, si usa un puntatore che "scorre" attraverso l'array.

// Pattern con puntatori: puntatore che scorre l'array
int somma_array_ptr(int *array, size_t size) {
    int somma = 0;
    int *end = array + size;  // Puntatore "one past the end"
    
    // Itera finché ptr < end
    for(int *ptr = array; ptr < end; ptr++) {
        somma += *ptr;  // Dereferenzia per ottenere il valore
    }
    
    return somma;
}

// Alternativa con while - molto comune in C classico
int somma_array_while(int *array, size_t size) {
    int somma = 0;
    int *ptr = array;
    
    while(size-- > 0) {  // Decrementa size ad ogni iterazione
        somma += *ptr++;  // Post-incremento: usa *ptr poi incrementa ptr
    }
    
    return somma;
}

// Spiegazione di *ptr++:
// È equivalente a: *(ptr++)
// 1. Dereferenzia ptr (ottiene il valore corrente)
// 2. Incrementa ptr (punta al prossimo elemento)
// Il post-incremento (ptr++) restituisce il valore PRIMA dell'incremento


// VANTAGGI dei puntatori:
// 1. Potenzialmente più efficiente (meno calcoli di indirizzo)
// 2. Stile più "C-like", preferito da veterani
// 3. Utile quando già lavori con puntatori
// 
// SVANTAGGI:
// 1. Meno leggibile per principianti
// 2. Non hai direttamente l'indice (devi calcolarlo: ptr - array)
// 3. Più facile fare errori con l'aritmetica dei puntatori


// Esempio: cercare un elemento con puntatori
int* trova_elemento(int *array, size_t size, int target) {
    int *end = array + size;
    
    for(int *ptr = array; ptr < end; ptr++) {
        if(*ptr == target) {
            return ptr;  // Restituisce puntatore all'elemento trovato
        }
    }
    
    return NULL;  // Non trovato
}

// Uso:
int numeri[] = {10, 20, 30, 40, 50};
int *trovato = trova_elemento(numeri, 5, 30);

if(trovato != NULL) {
    // Calcola l'indice dal puntatore
    size_t indice = trovato - numeri;  // Differenza di puntatori
    printf("Trovato alla posizione %zu\n", indice);
    
    // Puoi anche modificare attraverso il puntatore
    *trovato = 35;  // Cambia 30 in 35
}

✓ For Loop con Indice

Quando usarlo:
  • Codice per principianti o team misti
  • Quando serve l'indice per logica
  • Debug e manutenzione priorità
  • Accesso non sequenziale (salti, inverso)
Pro:
  • Massima chiarezza
  • Indice disponibile
  • Meno propenso a errori

➜ For Loop con Puntatori

Quando usarlo:
  • Codice critico per prestazioni
  • Librerie low-level
  • Quando già usi puntatori
  • Stile C idiomatico preferito
Pro:
  • Potenzialmente più veloce
  • Meno operazioni
  • Stile "più C"

Nota sulle prestazioni: Con i compilatori moderni e le loro ottimizzazioni aggressive, la differenza di prestazioni tra for-con-indice e for-con-puntatori è quasi sempre trascurabile. Il compilatore è molto bravo a ottimizzare entrambi i pattern. Quindi, la scelta dovrebbe basarsi principalmente su leggibilità e stile del team, non su presunte ottimizzazioni micro. Misura sempre con un profiler se le prestazioni sono critiche.

4. Array Multidimensionali: Oltre la Singola Dimensione

4.1 Concetto di Array Multidimensionali: Array di Array

Gli array multidimensionali sono, concettualmente, array i cui elementi sono a loro volta array. Sebbene spesso li visualizziamo come matrici bidimensionali (tabelle con righe e colonne) o strutture tridimensionali, è fondamentale capire che in memoria sono sempre memorizzati in modo lineare, secondo un ordine chiamato row-major order (ordine per righe) in C.

Un array bidimensionale int m[3][4] non è una "vera" matrice 3×4 nel senso matematico. È un array di 3 elementi, dove ogni elemento è un array di 4 interi. La distinzione è sottile ma importante per capire come funzionano l'indicizzazione e il passaggio a funzioni.

// Dichiarazione di array bidimensionale
int matrice[3][4];  
// Leggi come: "array di 3 elementi, ciascuno è un array di 4 int"
// Totale: 3 × 4 = 12 elementi int
// Memoria occupata: 12 × 4 byte = 48 byte (su sistema 32/64 bit)


// Inizializzazione - metodo gerarchico (preferito per leggibilità)
int m1[2][3] = {
    {1, 2, 3},    // Prima riga (m1[0])
    {4, 5, 6}     // Seconda riga (m1[1])
};

// Inizializzazione lineare (valida ma meno chiara)
int m2[2][3] = {1, 2, 3, 4, 5, 6};  
// Equivalente a m1, ma meno leggibile
// Gli elementi sono assegnati in row-major order: 
// m2[0][0]=1, m2[0][1]=2, m2[0][2]=3, m2[1][0]=4, m2[1][1]=5, m2[1][2]=6


// Inizializzazione parziale
int m3[3][3] = {
    {1, 2},       // Prima riga: {1, 2, 0}
    {3}            // Seconda riga: {3, 0, 0}
                    // Terza riga: {0, 0, 0} (completamente zero)
};

// Array tridimensionale: array di array di array
int cubo[2][3][4];  
// 2 "piani", ciascuno con 3 righe, ciascuna con 4 colonne
// Totale: 2 × 3 × 4 = 24 elementi int
// Memoria: 24 × 4 = 96 byte


// Inizializzazione di array 3D
int cubo_init[2][2][3] = {
    {   // Piano 0
        {1, 2, 3},    // Piano 0, Riga 0
        {4, 5, 6}     // Piano 0, Riga 1
    },
    {   // Piano 1
        {7, 8, 9},    // Piano 1, Riga 0
        {10, 11, 12}  // Piano 1, Riga 1
    }
};

4.2 Rappresentazione in Memoria: Row-Major Order

Capire come gli array multidimensionali sono disposti in memoria è cruciale per comprendere come accedervi efficientemente e come passarli alle funzioni. In C, gli array multidimensionali sono memorizzati in row-major order: prima tutte le colonne della prima riga, poi tutte le colonne della seconda riga, e così via.

Memoria di: int m[3][4] = {{1,2,3,4}, {5,6,7,8}, {9,10,11,12}}

Vista Logica (come la pensiamo - matrice):
m[0][0]=1   m[0][1]=2   m[0][2]=3   m[0][3]=4
m[1][0]=5   m[1][1]=6   m[1][2]=7   m[1][3]=8
m[2][0]=9   m[2][1]=10  m[2][2]=11  m[2][3]=12
                        
Vista Fisica (memoria lineare - come è realmente):
1 2 3 4 5 6 7 8 9 10 11 12
Prima riga (m[0]) Seconda riga (m[1]) Terza riga (m[2])
Implicazioni del Row-Major Order:
  • Cache Efficiency: Iterare per riga (indice esterno per righe, interno per colonne) è molto più efficiente perché accede alla memoria sequenzialmente, sfruttando la cache del processore.
  • Calcolo Indirizzo: Per m[i][j] con COLS colonne: indirizzo = base + (i × COLS + j) × sizeof(elemento)
  • Differenza da Fortran: Fortran usa column-major order (prima tutte le righe della prima colonna), importante quando interfacci C con codice Fortran.
  • Address Sanitizer (ASan): Strumento di runtime che instrumenta il codice per rilevare accessi fuori limite. Ottimo per debug e testing. (Flag: -fsanitize=address)
  • Strumenti di Analisi Statica e Dinamica:
    • Valgrind: Rileva memory leaks, accessi fuori limite, uso di memoria non inizializzata, ecc.
    • Static Analyzers: clang-tidy, Coverity, CodeQL analizzano il codice sorgente per trovare potenziali bug.